MVVM & MVC
MVC
View 传送指令到 Controller,Controller 完成业务逻辑后,要求 Model 改变状态,Model 将新的数据发送到 View,用户得到反馈。所有通信都是单向的。
MVVM
在 MVVM 中,UI 是通过数据驱动的,数据一旦改变就会相应的刷新对应的 UI,UI 如果改变,也会改变对应的数据。这种方式就可以在业务处理中只关心数据的流转,而无需直接和页面打交道。例如 Vue 中的数据劫持。不过 Vue 不是 MVVM 框架,只是有借鉴 MVVM 的思路。
View:界面Model:数据模型ViewModel:作为桥梁负责沟通View和Model
虚拟 DOM
虚拟 DOM 本质上是 js 对象,是对 DOM 的描述。但是 DOM 操作非常昂贵,导致应用程序行为缓慢且效率低下。
与真实 DOM 的差别
传统的 DOM 操作是直接在 DOM 上操作的,当需要修改一系列元素中的值时,就会直接对 DOM 进行操作。而采用 Virtual DOM 则会对需要修改的 DOM 进行比较(DIFF)之后,一次性将需要修改的部分进行 reconciliation。
- 创建真实 DOM 的代价高:真实的
DOM节点node实现的属性很多,而vnode仅仅实现一些必要的属性,相比起来,创建一个vnode的成本比较低。 - 触发多次浏览器重绘及回流:使用
vnode,相当于加了一个缓冲,让一次数据变动所带来的所有node变化,先在vnode中进行修改,然后diff之后对所有产生差异的节点集中一次对DOM tree进行修改,以减少浏览器的重绘及回流。
作用
- 虚拟 DOM 不会立马进行排版与重绘操作
- 虚拟 DOM 进行频繁修改,然后一次性比较并修改真实 DOM 中需要改的部分,最后在真实 DOM 中进行排版与重绘,减少过多 DOM 节点排版与重绘损耗
- 虚拟 DOM 有效降低大面积真实 DOM 的重绘与排版,因为最终与真实 DOM 比较 diff,找出有变化的 dom 节点,然后对其进行更新操作, 只渲染局部
缺点
- 首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,会比 innerHTML 插入慢。
虚拟 dom 的三个步骤
createElement()创建虚拟节点类,将真实DOM节点用js对象的形式进行展示,并提供render方法,将虚拟节点渲染成真实DOMdiff(oldNode,newNode)用一个新的 vnode 来和旧的 vnode 进行比较,得出新旧 dom 的差异,并将不同的操作都记录到patch对象patch():re-render:解析patch对象,进行re-render, 将差异应用到真实树上
Virtual DOM 真的比操作原生 DOM 快吗?谈谈你的想法。
- 原生 DOM 操作 vs. 通过框架封装操作。 这是一个性能 vs. 可维护性的取舍。框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。针对任何一个 benchmark,我都可以写出比任何框架更快的手动优化,但是那有什么意义呢?在构建一个实际应用的时候,你难道为每一个地方都去做手动优化吗?出于可维护性的考虑,这显然不可能。框架给你的保证是,你在不需要手动优化的情况下,我依然可以给你提供过得去的性能。
- 对 React 的 Virtual DOM 的误解。 React 从来没有说过 “React 比原生操作 DOM 快”。React 的基本思维模式是每次有变动就整个重新渲染整个应用。如果没有 Virtual DOM,简单来想就是直接重置 innerHTML。很多人都没有意识到,在一个大型列表所有数据都变了的情况下,重置 innerHTML 其实是一个还算合理的操作... 真正的问题是在 “全部重新渲染” 的思维模式下,即使只有一行数据变了,它也需要重置整个 innerHTML,这时候显然就有大量的浪费。
我们可以比较一下 innerHTML vs. Virtual DOM 的重绘性能消耗:
innerHTML: render html string O(template size) + 重新创建所有 DOM 元素 O(DOM size) Virtual DOM: render Virtual DOM + diff O(template size) + 必要的 DOM 更新 O(DOM change) Virtual DOM render + diff 显然比渲染 html 字符串要慢,但是!它依然是纯 js 层面的计算,比起后面的 DOM 操作来说,依然便宜了太多。可以看到,innerHTML 的总计算量不管是 js 计算还是 DOM 操作都是和整个界面的大小相关,但 Virtual DOM 的计算量里面,只有 js 计算和界面大小相关,DOM 操作是和数据的变动量相关的。前面说了,和 DOM 操作比起来,js 计算是极其便宜的。这才是为什么要有 Virtual DOM。
- MVVM vs. Virtual DOM 相比起 React,其他 MVVM 系框架比如 Angular, Knockout 以及 Vue、Avalon 采用的都是数据绑定:通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。MVVM 的性能也根据变动检测的实现原理有所不同:Angular 的脏检查使得任何变动都有固定的 O(watcher count) 的代价;Knockout/Vue/Avalon 都采用了依赖收集,在 js 和 DOM 层面都是 O(change):
脏检查:scope digest O(watcher count) + 必要 DOM 更新 O(DOM change) 依赖收集:重新收集依赖 O(data change) + 必要 DOM 更新 O(DOM change)可以看到,Angular 最不效率的地方在于任何小变动都有的和 watcher 数量相关的性能代价。但是!当所有数据都变了的时候,Angular 其实并不吃亏。依赖收集在初始化和数据变化的时候都需要重新收集依赖,这个代价在小量更新的时候几乎可以忽略,但在数据量庞大的时候也会产生一定的消耗。 MVVM 渲染列表的时候,由于每一行都有自己的数据作用域,所以通常都是每一行有一个对应的 ViewModel 实例,或者是一个稍微轻量一些的利用原型继承的 "scope" 对象,但也有一定的代价。所以,MVVM 列表渲染的初始化几乎一定比 React 慢,因为创建 ViewModel / scope 实例比起 Virtual DOM 来说要昂贵很多。这里所有 MVVM 实现的一个共同问题就是在列表渲染的数据源变动时,尤其是当数据是全新的对象时,如何有效地复用已经创建的 ViewModel 实例和 DOM 元素。假如没有任何复用方面的优化,由于数据是 “全新” 的,MVVM 实际上需要销毁之前的所有实例,重新创建所有实例,最后再进行一次渲染!这就是为什么题目里链接的 angular/knockout 实现都相对比较慢。相比之下,React 的变动检查由于是 DOM 结构层面的,即使是全新的数据,只要最后渲染结果没变,那么就不需要做无用功。
Angular 和 Vue 都提供了列表重绘的优化机制,也就是 “提示” 框架如何有效地复用实例和 DOM 元素。比如数据库里的同一个对象,在两次前端 API 调用里面会成为不同的对象,但是它们依然有一样的 uid。这时候你就可以提示 track by uid 来让 Angular 知道,这两个对象其实是同一份数据。那么原来这份数据对应的实例和 DOM 元素都可以复用,只需要更新变动了的部分。或者,你也可以直接 track by $index 来进行 “原地复用”:直接根据在数组里的位置进行复用。在题目给出的例子里,如果 angular 实现加上 track by $index 的话,后续重绘是不会比 React 慢多少的。甚至在 dbmonster 测试中,Angular 和 Vue 用了 track by $index 以后都比 React 快: dbmon (注意 Angular 默认版本无优化,优化过的在下面)
顺道说一句,React 渲染列表的时候也需要提供 key 这个特殊 prop,本质上和 track-by 是一回事。
- 性能比较也要看场合 在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。
初始渲染:Virtual DOM > 脏检查 >= 依赖收集 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化) > Virtual DOM 无优化 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化 不要天真地以为 Virtual DOM 就是快,diff 不是免费的,batching 么 MVVM 也能做,而且最终 patch 的时候还不是要用原生 API。在我看来 Virtual DOM 真正的价值从来都不是性能,而是它 1) 为函数式的 UI 编程方式打开了大门;2) 可以渲染到 DOM 以外的 backend,比如 ReactNative。
- 总结 以上这些比较,更多的是对于框架开发研究者提供一些参考。主流的框架 + 合理的优化,足以应对绝大部分应用的性能需求。如果是对性能有极致需求的特殊情况,其实应该牺牲一些可维护性采取手动优化:比如 Atom 编辑器在文件渲染的实现上放弃了 React 而采用了自己实现的 tile-based rendering;又比如在移动端需要 DOM-pooling 的虚拟滚动,不需要考虑顺序变化,可以绕过框架的内置实现自己搞一个。
虚拟 DOM 优势
vdom 是对类似 HTML 节点的一层描述。采用 Virtual DOM 则会对需要修改的 DOM 进行比较(DIFF),从而只选择需要修改的部分。也因此对于不需要大量修改 DOM 的应用来说,采用 Virtual DOM 并不会有优势。
首先 DOM 是一个多叉树的结构,如果需要完整的对比两颗树的差异,那么需要的时间复杂度会是 O(n ^ 3),这个复杂度肯定是不能接受的。于是 React 团队优化了算法,实现了 O(n) 的复杂度来对比差异。 实现 O(n) 复杂度的关键就是只对比同层的节点,而不是跨层对比,这也是考虑到在实际业务中很少会去跨层的移动 DOM 元素。 所以判断差异的算法就分为了两步
- 首先从上至下,从左往右遍历对象,也就是树的深度遍历,这一步中会给每个节点添加索引,便于最后渲染差异
- 一旦节点有子元素,就去判断子元素是否有不同
在第一步算法中我们需要判断新旧节点的 tagName 是否相同,如果不相同的话就代表节点被替换了。如果没有更改 tagName 的话,就需要判断是否有子元素,有的话就进行第二步算法。
在第二步算法中,我们需要判断原本的列表中是否有节点被移除,在新的列表中需要判断是否有新的节点加入,还需要判断节点是否有移动。
举个例子来说,假设页面中只有一个列表,我们对列表中的元素进行了变更
// 假设这里模拟一个 ul,其中包含了 5 个 li
[1, 2, 3, 4, 5]
// 这里替换上面的 li
[1, 2, 5, 4]
从上述例子中,我们一眼就可以看出先前的 ul 中的第三个 li 被移除了,四五替换了位置。
那么在实际的算法中,我们如何去识别改动的是哪个节点呢?这就引入了 key 这个属性,想必大家在 Vue 或者 React 的列表中都用过这个属性。这个属性是用来给每一个节点打标志的,用于判断是否是同一个节点。
当然在判断以上差异的过程中,我们还需要判断节点的属性是否有变化等等。
当我们判断出以上的差异后,就可以把这些差异记录下来。当对比完两棵树以后,就可以通过差异去局部更新 DOM,实现性能的最优化。
当然了 Virtual DOM 提高性能是其中一个优势,其实最大的优势还是在于:
- 将 Virtual DOM 作为一个兼容层,让我们还能对接非 Web 端的系统,实现跨端开发。
- 同样的,通过 Virtual DOM 我们可以渲染到其他的平台,比如实现 SSR、同构渲染等等。
- 实现组件的高度抽象化
vue 为什么采用 vdom?
性能受场景的影响是非常大的,不同的场景可能造成不同实现方案之间成倍的性能差距,所以依赖细粒度绑定及 Virtual DOM 哪个的性能更好还真不是一个容易下定论的问题。
Vue 之所以引入了 Virtual DOM,更重要的原因是为了解耦 HTML依赖,这带来两个非常重要的好处是:
- 不再依赖
HTML解析器进行模版解析,可以进行更多的AOT工作提高运行时效率:通过模版AOT编译,Vue的运行时体积可以进一步压缩,运行时效率可以进一步提升; - 可以渲染到
DOM以外的平台,实现SSR、同构渲染这些高级特性,Weex等框架应用的就是这一特性。
综上,Virtual DOM 在性能上的收益并不是最主要的,更重要的是它使得 Vue 具备了现代框架应有的高级特性。
Virtual Dom 算法实现
树的递归
- 新的节点的
tagName或者key和旧的不同,这种情况代表需要替换旧的节点,并且也不再需要遍历新旧节点的子元素了,因为整个旧节点都被删掉了 - 新的节点的
tagName和key(可能都没有)和旧的相同,开始遍历子树 - 没有新的节点,那么什么都不用做
import { StateEnums, isString, move } from "./util";
import Element from "./element";
export default function diff(oldDomTree, newDomTree) {
// 用于记录差异
let pathchs = {};
// 一开始的索引为 0
dfs(oldDomTree, newDomTree, 0, pathchs);
return pathchs;
}
function dfs(oldNode, newNode, index, patches) {
// 用于保存子树的更改
let curPatches = [];
// 需要判断三种情况
// 1.没有新的节点,那么什么都不用做
// 2.新的节点的 tagName 和 `key` 和旧的不同,就替换
// 3.新的节点的 tagName 和 key(可能都没有) 和旧的相同,开始遍历子树
if (!newNode) {
} else if (newNode.tag === oldNode.tag && newNode.key === oldNode.key) {
// 判断属性是否变更
let props = diffProps(oldNode.props, newNode.props);
if (props.length) curPatches.push({ type: StateEnums.ChangeProps, props });
// 遍历子树
diffChildren(oldNode.children, newNode.children, index, patches);
} else {
// 节点不同,需要替换
curPatches.push({ type: StateEnums.Replace, node: newNode });
}
if (curPatches.length) {
if (patches[index]) {
patches[index] = patches[index].concat(curPatches);
} else {
patches[index] = curPatches;
}
}
}
判断属性的更改
判断属性的更改也分三个步骤
- 遍历旧的属性列表,查看每个属性是否还存在于新的属性列表中
- 遍历新的属性列表,判断两个列表中都存在的属性的值是否有变化
- 在第二步中同时查看是否有属性不存在与旧的属性列列表中
function diffProps(oldProps, newProps) {
// 判断 Props 分以下三步骤
// 先遍历 oldProps 查看是否存在删除的属性
// 然后遍历 newProps 查看是否有属性值被修改
// 最后查看是否有属性新增
let change = [];
for (const key in oldProps) {
if (oldProps.hasOwnProperty(key) && !newProps[key]) {
change.push({
prop: key,
});
}
}
for (const key in newProps) {
if (newProps.hasOwnProperty(key)) {
const prop = newProps[key];
if (oldProps[key] && oldProps[key] !== newProps[key]) {
change.push({
prop: key,
value: newProps[key],
});
} else if (!oldProps[key]) {
change.push({
prop: key,
value: newProps[key],
});
}
}
}
return change;
}
判断列表差异算法实现
这里的主要步骤其实和判断属性差异是类似的,也是分为三步
- 遍历旧的节点列表,查看每个节点是否还存在于新的节点列表中
- 遍历新的节点列表,判断是否有新的节点
- 在第二步中同时判断节点是否有移动
function listDiff(oldList, newList, index, patches) {
// 为了遍历方便,先取出两个 list 的所有 keys
let oldKeys = getKeys(oldList);
let newKeys = getKeys(newList);
let changes = [];
// 用于保存变更后的节点数据
// 使用该数组保存有以下好处
// 1.可以正确获得被删除节点索引
// 2.交换节点位置只需要操作一遍 DOM
// 3.用于 `diffChildren` 函数中的判断,只需要遍历
// 两个树中都存在的节点,而对于新增或者删除的节点来说,完全没必要
// 再去判断一遍
let list = [];
oldList &&
oldList.forEach((item) => {
let key = item.key;
if (isString(item)) {
key = item;
}
// 寻找新的 children 中是否含有当前节点
// 没有的话需要删除
let index = newKeys.indexOf(key);
if (index === -1) {
list.push(null);
} else list.push(key);
});
// 遍历变更后的数组
let length = list.length;
// 因为删除数组元素是会更改索引的
// 所有从后往前删可以保证索引不变
for (let i = length - 1; i >= 0; i--) {
// 判断当前元素是否为空,为空表示需要删除
if (!list[i]) {
list.splice(i, 1);
changes.push({
type: StateEnums.Remove,
index: i,
});
}
}
// 遍历新的 list,判断是否有节点新增或移动
// 同时也对 `list` 做节点新增和移动节点的操作
newList &&
newList.forEach((item, i) => {
let key = item.key;
if (isString(item)) {
key = item;
}
// 寻找旧的 children 中是否含有当前节点
let index = list.indexOf(key);
// 没找到代表新节点,需要插入
if (index === -1 || key == null) {
changes.push({
type: StateEnums.Insert,
node: item,
index: i,
});
list.splice(i, 0, key);
} else {
// 找到了,需要判断是否需要移动
if (index !== i) {
changes.push({
type: StateEnums.Move,
from: index,
to: i,
});
move(list, index, i);
}
}
});
return { changes, list };
}
function getKeys(list) {
let keys = [];
let text;
list &&
list.forEach((item) => {
let key;
if (isString(item)) {
key = [item];
} else if (item instanceof Element) {
key = item.key;
}
keys.push(key);
});
return keys;
}
遍历子元素打标识
对于这个函数来说,主要功能就两个
- 判断两个列表差异
- 给节点打上标记
总体来说,该函数实现的功能很简单
function diffChildren(oldChild, newChild, index, patches) {
let { changes, list } = listDiff(oldChild, newChild, index, patches);
if (changes.length) {
if (patches[index]) {
patches[index] = patches[index].concat(changes);
} else {
patches[index] = changes;
}
}
// 记录上一个遍历过的节点
let last = null;
oldChild &&
oldChild.forEach((item, i) => {
let child = item && item.children;
if (child) {
index =
last && last.children ? index + last.children.length + 1 : index + 1;
let keyIndex = list.indexOf(item.key);
let node = newChild[keyIndex];
// 只遍历新旧中都存在的节点,其他新增或者删除的没必要遍历
if (node) {
dfs(item, node, index, patches);
}
} else index += 1;
last = item;
});
}
渲染差异
通过之前的算法,我们已经可以得出两个树的差异了。既然知道了差异,就需要局部去更新 DOM 了,下面就让我们来看看 Virtual Dom 算法的最后一步骤
这个函数主要两个功能
- 深度遍历树,将需要做变更操作的取出来
- 局部更新 DOM
整体来说这部分代码还是很好理解的
let index = 0;
export default function patch(node, patchs) {
let changes = patchs[index];
let childNodes = node && node.childNodes;
// 这里的深度遍历和 diff 中是一样的
if (!childNodes) index += 1;
if (changes && changes.length && patchs[index]) {
changeDom(node, changes);
}
let last = null;
if (childNodes && childNodes.length) {
childNodes.forEach((item, i) => {
index =
last && last.children ? index + last.children.length + 1 : index + 1;
patch(item, patchs);
last = item;
});
}
}
function changeDom(node, changes, noChild) {
changes &&
changes.forEach((change) => {
let { type } = change;
switch (type) {
case StateEnums.ChangeProps:
let { props } = change;
props.forEach((item) => {
if (item.value) {
node.setAttribute(item.prop, item.value);
} else {
node.removeAttribute(item.prop);
}
});
break;
case StateEnums.Remove:
node.childNodes[change.index].remove();
break;
case StateEnums.Insert:
let dom;
if (isString(change.node)) {
dom = document.createTextNode(change.node);
} else if (change.node instanceof Element) {
dom = change.node.create();
}
node.insertBefore(dom, node.childNodes[change.index]);
break;
case StateEnums.Replace:
node.parentNode.replaceChild(change.node.create(), node);
break;
case StateEnums.Move:
let fromNode = node.childNodes[change.from];
let toNode = node.childNodes[change.to];
let cloneFromNode = fromNode.cloneNode(true);
let cloenToNode = toNode.cloneNode(true);
node.replaceChild(cloneFromNode, toNode);
node.replaceChild(cloenToNode, fromNode);
break;
default:
break;
}
});
}
由于真实 DOM 操作非常昂贵,并且直接影响到应用程序的性能和用户体验。因此,虚拟 DOM 提供了一种更高效的方式来操作 DOM,它可以利用 JavaScript 引擎更高效地更新 DOM,并且可以避免频繁的重绘、回流等操作,从而提高应用程序的性能。